Web Push 可以让网站在用户授权后,即使页面未打开,也向用户发送通知。本文以 Nuxt 3 为例,介绍如何使用 web-push 实现浏览器推送。
一、工作原理
Web Push 的基本流程如下:
前端注册 Service Worker。
用户授权通知权限。
浏览器生成 Push Subscription。
前端将 Subscription 发送到 Nuxt 服务端。
服务端保存 Subscription。
服务端使用
web-push发送消息。Service Worker 接收消息并显示通知。
二、安装依赖
安装 web-push:
npm install web-push
生成 VAPID 密钥:
npx web-push generate-vapid-keys
命令会生成一对公钥和私钥。
在项目根目录创建 .env:
VAPID_PUBLIC_KEY=你的_public_key
VAPID_PRIVATE_KEY=你的_private_key
VAPID_SUBJECT=mailto:admin@example.com
其中:
VAPID_PUBLIC_KEY:前端订阅时使用。VAPID_PRIVATE_KEY:仅服务端使用。VAPID_SUBJECT:通常填写管理员邮箱。
三、配置 Nuxt 运行时变量
修改 nuxt.config.ts:
export default defineNuxtConfig({
runtimeConfig: {
vapidPrivateKey: process.env.VAPID_PRIVATE_KEY,
vapidSubject: process.env.VAPID_SUBJECT,
public: {
vapidPublicKey: process.env.VAPID_PUBLIC_KEY
}
}
})
私钥放在非 public 配置中,避免泄露给客户端。
四、创建 Service Worker
在 Nuxt 项目中创建:
public/sw.js
内容如下:
self.addEventListener('push', event => {
let data = {}
try {
data = event.data ? event.data.json() : {}
} catch {
data = {
title: '新消息',
body: event.data ? event.data.text() : ''
}
}
const title = data.title || '新消息'
event.waitUntil(
self.registration.showNotification(title, {
body: data.body || '',
icon: data.icon || '/icon-192.png',
badge: data.badge || '/icon-192.png',
data: {
url: data.url || '/'
}
})
)
})
self.addEventListener('notificationclick', event => {
event.notification.close()
const url = event.notification.data?.url || '/'
event.waitUntil(
clients.matchAll({
type: 'window',
includeUncontrolled: true
}).then(windowClients => {
for (const client of windowClients) {
if ('focus' in client) {
client.navigate(url)
return client.focus()
}
}
return clients.openWindow(url)
})
)
})
这个 Service Worker 主要完成两个功能:
接收推送并显示通知。
用户点击通知后打开指定页面。
五、创建订阅接口
创建文件:
server/api/push/subscribe.post.ts
import webpush from 'web-push'
export default defineEventHandler(async event => {
const config = useRuntimeConfig()
const subscription = await readBody(event)
webpush.setVapidDetails(
config.vapidSubject,
config.public.vapidPublicKey,
config.vapidPrivateKey
)
// 实际项目中应保存到数据库
console.log('收到订阅:', subscription)
return {
success: true,
message: '订阅成功'
}
})
前端提交的 Subscription 通常包含:
{
"endpoint": "...",
"expirationTime": null,
"keys": {
"p256dh": "...",
"auth": "..."
}
}
生产环境中应该将这些数据与用户 ID 绑定后保存到数据库。
六、创建发送推送接口
创建文件:
server/api/push/send.post.ts
import webpush from 'web-push'
export default defineEventHandler(async event => {
const config = useRuntimeConfig()
const body = await readBody(event)
webpush.setVapidDetails(
config.vapidSubject,
config.public.vapidPublicKey,
config.vapidPrivateKey
)
const subscription = body.subscription
if (!subscription) {
throw createError({
statusCode: 400,
statusMessage: '缺少 subscription'
})
}
const payload = JSON.stringify({
title: body.title || 'Nuxt 推送',
body: body.message || '你收到一条新消息',
url: body.url || '/'
})
try {
await webpush.sendNotification(subscription, payload)
return {
success: true
}
} catch (error: any) {
console.error(error)
throw createError({
statusCode: 500,
statusMessage: '推送发送失败'
})
}
})
实际项目中不应该从请求参数直接接收 Subscription,而应该根据用户身份从数据库查询对应的订阅信息。
七、前端订阅推送
例如在 pages/index.vue 中实现:
<script setup lang="ts">
const config = useRuntimeConfig()
const subscribed = ref(false)
const message = ref('')
function urlBase64ToUint8Array(base64String: string) {
const padding = '='.repeat((4 - base64String.length % 4) % 4)
const base64 = (base64String + padding)
.replace(/-/g, '+')
.replace(/_/g, '/')
const rawData = window.atob(base64)
return Uint8Array.from(
[...rawData].map(char => char.charCodeAt(0))
)
}
async function subscribePush() {
if (!('serviceWorker' in navigator)) {
message.value = '浏览器不支持 Service Worker'
return
}
if (!('PushManager' in window)) {
message.value = '浏览器不支持 Web Push'
return
}
const permission = await Notification.requestPermission()
if (permission !== 'granted') {
message.value = '用户拒绝了通知权限'
return
}
const registration = await navigator.serviceWorker.register('/sw.js')
let subscription = await registration.pushManager.getSubscription()
if (!subscription) {
subscription = await registration.pushManager.subscribe({
userVisibleOnly: true,
applicationServerKey: urlBase64ToUint8Array(
config.public.vapidPublicKey
)
})
}
await $fetch('/api/push/subscribe', {
method: 'POST',
body: subscription.toJSON()
})
subscribed.value = true
message.value = '订阅成功'
}
async function sendTestPush() {
const registration = await navigator.serviceWorker.ready
const subscription = await registration.pushManager.getSubscription()
if (!subscription) {
message.value = '请先订阅推送'
return
}
await $fetch('/api/push/send', {
method: 'POST',
body: {
subscription: subscription.toJSON(),
title: '测试通知',
message: '这是一条来自 Nuxt 的 Web Push 消息',
url: '/'
}
})
message.value = '推送已发送'
}
</script>
<template>
<div>
<button @click="subscribePush">
订阅通知
</button>
<button
:disabled="!subscribed"
@click="sendTestPush"
>
发送测试通知
</button>
<p>{{ message }}</p>
</div>
</template>
八、测试流程
启动 Nuxt 项目:
npm run dev
然后访问:
http://localhost:3000
依次执行:
点击“订阅通知”。
允许浏览器通知权限。
点击“发送测试通知”。
浏览器显示推送通知。
本地开发通常可以使用 localhost,但生产环境必须使用 HTTPS。
九、生产环境注意事项
1. 使用 HTTPS
Service Worker 和 Web Push 通常要求安全上下文:
生产环境必须使用 HTTPS。
本地开发可以使用
localhost。
2. 不要暴露私钥
以下变量只能在服务端使用:
VAPID_PRIVATE_KEY
VAPID_SUBJECT
前端只能获取:
VAPID_PUBLIC_KEY
3. 保存订阅信息
Subscription 应保存到数据库,并与用户绑定,例如:
user_id
endpoint
p256dh
auth
created_at
updated_at
发送时,根据用户 ID 查找订阅信息。
4. 删除失效订阅
当 webpush.sendNotification() 返回 404 或 410 时,通常代表订阅已经失效,应从数据库删除。
5. 处理重复订阅
用户重复进入页面时,可以先调用:
registration.pushManager.getSubscription()
如果已经存在订阅,就不需要重复创建。
6. 需要用户主动授权
通知权限最好在用户点击按钮后申请,不要在页面加载时自动弹出授权窗口。
十、PWA 与 Web Push 的关系
Web Push 不一定要求完整的 PWA,但必须使用 Service Worker。
如果希望网站能够:
安装到桌面;
像 App 一样独立运行;
显示应用图标;
离线访问部分内容;
则还需要配置 PWA,包括:
manifest.json应用图标
Service Worker
display: standalone
因此,Web Push 是推送能力,PWA 是网站应用化能力,两者可以结合使用,但不是完全等同的技术。